第7章 文档结构和库

本章内容:前面的学习基本完成了制作简单游戏所有必备的知识,案例也几乎就是完整的小游戏了。但是随着我们代码量的增加,我们发现使用单一的文档结构看起来比较混乱,不再能够满足我们的项目了,这章我们讲解如何把一个项目合理的进行文件布局。同时,我们会看一些库文件的代码,来让他们更加规范的加入到我们的项目。

游戏文档结构以及库使用规范

文档结构,看起来很高大上的样子,实际上就是你怎么安排你的文件更加合理,合理的理解为你想找的东西,你一下子就能找到。而每个人的思维习惯不同,这也导致文档结构的千差万别,下面介绍的,仅仅是笔者对文档结构的一个习惯性安排,读者可以根据自身需求安排自己想要的模式。

文档架构

  1. conf.lua
    从读取顺序来讲,这个文件是最先读取的,它的作用是在引擎初始化时,对其进行设置。各个设置请自行wiki。
    1
    io.stdout:setvbuf("no")

这个代码一般我加入到conf里,它控制着print这种标准输出是否缓存。如果缓存的话,只有当缓存溢出或结束时才会输出。这种是为了避免过多的文件读写。但我们print主要用于debug,所以要关闭缓存。

  1. main.lua
    个人喜好上,把所有需要引用的库以及全局变量放到这里。避免在此文件以外定义全局变量。过多的全局变量不但会增加代码出错的因素,而且会降低效率。

  2. 场景库以及场景
    一般而言,游戏是分场景的,比如入场的splash(中文咋说,就是商标,团队图标等淡入淡出),游戏开始菜单,游戏场景,结束画面等。下面解释一下场景库的原理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    state = {}
    state[1] = {
    load = function() print("load1")end,
    enter = function() print("enter1") end,
    update = function() print("update1") end,
    draw = function() print("draw1") end,
    leave = function() print("leave1") end,
    }
    state[2] = {
    load = function() print("load2")end,
    enter = function() print("enter2") end,
    update = function() print("update2") end,
    draw = function() print("draw2") end,
    leave = function() print("leave1") end,
    }
    for i,v in ipairs(state) do
    state[i]:load()
    end
    local cStateIndex =1
    function setState(index)
    state[cStateIndex]:leave()
    local current = state[index]
    current:enter()
    love.update = current.update
    love.draw = current.draw
    cStateIndex = index
    end
    setState[2]

解读一下上面的代码,首先定义了两个state,他们相当于两个场景,他们都有各自的load,update,draw方法,对应着love的回调,但是又多了一个enter方法和一个leave方法。他们意思是进入和退出场景时的回调。
然后对每一个场景进行load,当然,你也可以动态的load,只不过尚未读取的场景是不能被使用的。
接下来是设置场景的方法setState,从函数内部我们可以看出,它实际上是动态的赋予love.update和love.draw于我们场景对应的方法,从而实现场景跳转。当然,还有从上一个场景离开和新场景进入的方法。
上面的代码仅仅是为了解释场景的工作原理,除了update,draw之外,所有love回调都需要被重新定向。比较完整的场景库之一hump.gamestate是笔者比较喜欢的场景库,下面有一段我惯用的导入场景的方法。

1
2
3
4
5
6
7
8
9
10
gamestate= require "lib/hump/gamestate"
function love.load()
gameState={}
for _,name in ipairs(love.filesystem.getDirectoryItems("scene")) do
local filename = name:sub(1,-5) --“.lua”四个字符
gameState[filename]=require("scene."..filename)
end
gamestate.registerEvents()
gamestate.switch(gameState.splash)
end

上面的代码的含义是我们从scene文件夹中读取所有文件,除去.lua四个字符为名字,定义场景。然后跳转场景到splash场景。这里有个filesystem的方法。这里只知道含义即可。

  1. 文件夹
    上面说到了scene文件夹,我们就系统说一下我的一般项目文件夹的排布,当然并不是统一的,根据自己的喜好来。
    首先,我们知道main.lua和conf.lua只有放在根目录下才能发挥作用。这个不变。
    然后是library目录,里面存放第三方或者自己写的库文件。至于库文件的规范待会说。
    然后是scene目录,存放各种场景文件。
    接下来asset目录,存放各种资源,包括image,sound,mucis,font,tile,spine等子目录。
    然后是object目录,存放游戏对象的类模板。
    最后是misc目录,各种各样的单个脚本,或者不适合放在其他目录的文件。
    当然,上面是以类型为分类的,也可以以游戏对象为分类,把一种游戏对象的资源放在一个文件夹下。如game目录,player目录,enemy目录等,将类文件,素材文件,读取脚本等都放在一起。

文件和库的导入。

lua的require机制需要比较熟悉,require的参数为一个lua文件,且不含.lua文件拓展名。其实现的实际上是下面代码:

1
2
3
local abc = require "file" 等价于
local func = function() 文件内容 end
local abc = func(path/to/file)

你需要理解的是:
首先,任何单独的lua文件都是被看做一个function的,如果你在文件中不注册全局变量也没有返回值,那他不会对其他文件产生污染。也就是要么你在文件中写个全局变量或者注册在全局变量下,比如love.yourlib,要么你需要有一个返回值return,不然你的文件等于0。
然后,通过require引入的文件具有唯一性,就是无论你引入多少次require该文件,它返回的都是同一个引用(ref/指针),所以,你无法使用这种方法来作为模板复制。但是有下面几种情况:

1
2
3
4
5
6
7
8
9
--in the file
return function()
local tab = {
test = "123"
}
return tab
end
-- in main
local test = require("file")()

这种是返回一个function,所以在使用时,还需要再call一下。
如果希望每次都返回不同的ref的话,需要使用另一种方法。

1
2
3
local func = loadstring(love.filesystem.read("abc.lua"))
local func = loadfile("abc.lua")
local test = func()

刚刚讲了,一个文件的本质是一个function,所以上面的方法的返回值也是function需要再call一下才能发挥作用。
另外一个很神奇的函数setfenv(func,env),它可以对一个函数指定外部环境,也就是说,把这个func放在env块下。但是,注意的是,如果使用这个,env是个很纯净的空间,是没有任何love和lua自带环境的,所以,你需要手动导入或者设置metatable.

1
2
3
4
5
6
7
-- in the file
abc = "123"
-- in main
local func = loadfile("abc.lua")
local test = {}
setfenv(func,test)
print(test.abc,abc)

我们会发现,文件中的abc并没有污染到全局变量,而是成为了test的变量。这种形式比较适合在abc.lua中写脚本,因为书写会比较方便。而且不会污染外部。

库是love的一个使用核心,因为Love本身不提供高级功能,而库是已经用原始语言编写好的一些具有一定功能的代码块。通过引入他们可以方便的形成一些游戏功能。它也是代码复用的一种形式。另外,虽然love的luajit支持动态链接库,但由于我们希望一套代码对应所有平台,因此,尽量使用lua为语言的库。当然,lua功能无法支持到的函数或者某些比较需要效率的函数,就只能外部引入c库了。
下面介绍一种库的形式

单例库

这种库一般在游戏中只需要一个实例,或者仅仅是一个表,表下有若干的函数。这种库,直接用一个本地变量传入就可以了。比如

1
2
local lib = require "math"
local test = lib.add(1,2)

这种库有时候也会自动的在全局里面注册一个变量来承载他们。但是这种做法并不推荐,因为我们一般不看库内部的文件,而内部注册的东西很有可能被外部某个同名变量覆盖,所以,不要这么做。

即时库

这种库是最爽的了,所谓即时(IMM)式,是它在内部,几乎不(或者完全不)存储来自外部的数据,而是在每帧把数据手动的传入到库中,使其发挥作用,类似于面向组件编程那样。 这种库要求每帧都要将数据传入库中,可能对于用惯oop的人不太适应,但是它的优点在于,它太干净了。我完全不用担心什么东西没有被释放。只要不向里面传东西,这个对象就不存在。比较有名的包括:UI库suit和多边形碰撞库hardoncollider,他们是同一个人写的(hump也是他写的),文档十分详细。

对象库

这是最常见的一种库,他本身是一个类,通过call或者new方法来产生实例。而实例具有我们有用的方法,原理就是lua的metatable大法。这种库太多了,比如我们之前用到的bump,vector,tween等。

补丁库

这也是比较常见的一种做法。通过库里的代码,扩充现有的功能。比如math库,string库,love库等等。

1
2
3
4
5
function math.sign(x)
if x>0 then return 1
elseif x<0 then return -1
else return 0 end
end

上面是个简单的例子。这种库,直接require即可。另外还有一些快捷方式的补丁:

1
2
3
lg = love.graphics
lm = love.math
gprint = love.graphics.print

猴子补丁

首先可以百度一下这种补丁是如何从大猩猩到猴子的^^,首先要说的是,这种补丁很灵活,也很危险。它是动态的替换一些原来系统已经存在的功能,使其具有另一项功能。因为lua中的所有值(表和函数)都是承接在__G(全局)中,修改一个内建函数跟改其他表的键值是一样的。这里举个简单的例子:

1
2
3
4
local _print = print
print = function(...)
_print("monkey printing: ", ...)
end

以后调用print的你就成了猴子-。-;另一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
local _circle = love.graphics.circle
function love.graphics.circle(mode,...)
if mode == "outline" then
local r,g,b,a = love.graphics.getColor()
_circle("line",...)
love.graphics.setColor(r,g,b,a/3)
_circle("fill",...)
love.graphics.setColor(r,g,b,a)
else
_circle(mode,...)
end
end

上面的函数就对circle进行了补丁,使其支持”outline”作为参数。

自定义库

用户可以自己写库,或者根据某些现有的库来加入自己的功能。或者仅仅是自己常用的代码段。在自己写库时,要遵循下面几个原则。

  1. 不必要时,不要污染全局变量。
  2. 尽量不要产生额外的数据,而是利用传入的数据。比如在库中经常出现(function() return {} end)这种形式的代码。
  3. 如果使用多文件形式的库,需要建立库名文件夹,库的入口在init.lua中,另外还要注意,多文件相互引用时,需要对文件位置做判断,常见的方法 local BASE = (…) .. ‘.’,base为当前目录。
  4. 尽量少做猴子补丁,除非你清除这意味着什么。因为它会极大降低程序的兼容性,以及给debug添乱。

编程时间

本单元不再进行新的内容了,而是完成之前的飞机游戏为一个完整的游戏。

设计阶段

本游戏定位为一个生存类射击游戏。玩家使用鼠标控制飞机移动,鼠标按下时发射子弹。敌人会随机的从地图外围进入场地,并以当前玩家位置,按固定角度飞行,期间自动发射子弹,敌人离开外围时重新生成一个。子弹在离开外围时销毁。子弹玩家或敌人扣减生命值,敌人死亡则销毁,玩家加分。玩家生命值为0时游戏结束。

  1. 使用库
    碰撞库:bump, 类库middleclass, 延迟库delay, 动画库anim8, 场景库hump.gamestate, 个人代码片段util
  2. 建立场景
    intro场景,显示游戏名称和一个背景图片,按任意键后进入游戏场景;
    game场景,以上述游戏规则,玩家生命值在玩家飞机上方显示绿条。敌人不显示生命。当玩家生命为0时,延迟2两秒进入到gameover场景。
    gamover场景:用红色显示背景图片,按esc结束游戏,按回车则重新开始。
  3. 导入资源
    本项目只有一个spritesheet资源。
  4. 建立游戏对象
    玩家飞机类,使用anim8建立动画对象,建立bump碰撞盒,飞机转向玩家鼠标的方向。按住鼠标左键开枪。绘制时除了动画,还需要一个血条显示。
    敌人飞机类,大多数跟玩家一样,初始位置需要随机在画面外围,并指向画面中心,飞行过程不改变方向,发射低速子弹。飞机在飞离画面后,随机外围重新生成。
    子弹类,子弹初始位置为飞机的发射位置,子弹需要留下发射的ref(引用),以便判断子弹是否来自于友方。子弹沿固定路线,固定速度飞行。子弹飞离外围后销毁。
    爆炸类,仅仅是一个爆炸动画,在life结束后,删除这个对象。
  5. 碰撞设计
    子弹之间无碰撞,飞机之间无碰撞。只有子弹与敌方的飞机(玩家子弹-敌人飞机,敌人子弹-玩家飞机)能够发生碰撞。碰撞时,子弹销毁。被击中飞机生命值降低hp,当生命值为0时飞机爆炸,并产生爆炸画面。

实现阶段

这里只讲一些难点代码。

1
2
3
4
5
6
7
8
9
10
game = {}
game.bullets = {}
game.planes = {}
game.others = {}
game.timer = 0
game.count = 0
game.planes[1] = Player(400,300,0)
for i = 1, 5 do
table.insert(game.planes,Enemy())
end

游戏沙盒的建立,之所以建立游戏沙盒,是因为我们希望我们的游戏对象们有一个家,而且不到处乱跑。这样我们可以很方便的找到他们的同时,也可以清理沙盒来重新游戏。因为直接重新给game赋值就可以重置了。这里面我们把敌我的飞机都放在planes里,是因为他们的行为模式类似,不需要额外建立表来存储。bullets出于未来可能进行单独遍历的原因,单独拿出来,不然也可以放在planes里面。others存储那些没有碰撞的对象。timer和count分别代表游戏时间和击毁敌机数量。
下面的实例化代码很简单,这里就不讲了。

bump的初始化及碰撞盒绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
game.world = bump.newWorld(64) --初始化世界
function Player:initBump() --碰撞盒绑定
game.world:add(self,self.x - self.w/2 ,self.y -self.w/2,
self.w,self.w)
end
for i = #game.planes,1 ,-1 do
local go = game.planes[i]
go:update(dt)
if go.destroyed then
game.world:remove(go) --碰撞盒解除
table.remove(game.planes,i)
end
end

上面的代码介绍了如何初始化世界,绑定碰撞盒和解除绑定。讲解几个要点。
初始化世界的参数的含义是格子的大小。一般而言,与我们基本的游戏单位大小相当就可以了。至于这个大小有什么用,我们之前碰撞中讲过,是一种格子优化。另外,不同的世界,拥有不同的碰撞,他们是不相关的,在重置游戏时,直接建立新世界,就不用对过去生成的碰撞盒进行一一删除了。
因为游戏对象和碰撞盒分别处于两个世界,因此删除一个世界的对象并不意味着对应的对象能够自动解除。因此,在删除游戏对象时,要手动的删除碰撞盒。不然它会永远停留在那里产生碰撞,而且无法释放游戏对象的资源,因为碰撞盒有对游戏对象的引用。

延迟命令

1
2
3
4
5
6
7
8
function Player:damage(damage)
self.hp = self.hp - damage
if self.hp<0 then
self.destroyed = true
table.insert(game.others,Boom(self.x,self.y))
delay:new(2,function() gamestate.switch(gameState.gameover,math.ceil(game.timer),game.count) end)
end
end

上面的代码完成了关于玩家收到伤害及场景切换的逻辑。其中用到了一个delay库。这个库很小,是笔者自己写的,原理也很简单。就是一个定制化的计时器,含义为在2秒之后,执行参数2中的函数。这个库要求在全局的update中添加delay:update(dt)才能够运行。
延迟库除了延迟功能本身,最大的一个特点是可以利用代码所在块的uppervalue,也就是上查值。关于上查值的意义在于。你可以使用定义这个函数内部的局域变量,而不用担心调用时的环境。关于Lua上查值的相关概念,请自行百度。

碰撞遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local x,y,cols = game.world:move(
self,self.x-self.radius,self.y-self.radius,
function() return bump.Response_Cross end)
for i,col in ipairs(cols) do
local other = col.other
local tag = other.tag
if tag ~= "bullet" then --我们不管子弹碰撞
if self.parent.tag ~= tag then -- 如果射出子弹的标签与目标标签不同,则击中
other:damage(self.damage)
self.destroyed = true
end
end
end

这里截取的是bullet的碰撞。之前提到过,bump特点之一就是只有在物体移动时,主动手动遍历,才有相关的动作,这样提高了运行效率。
bump的碰撞分为3个部分,filter,move,collision。
filter类似于分组,它是一个函数参数,通过判断碰撞体游戏对象来返回碰撞类型。比如马里奥和墙返回滑动,马里奥和硬币返回穿过,马里奥和弹簧返回反弹。
move是将碰撞盒随着游戏对象的位置变化移动到指定位置,同时根据filter的碰撞类型,通过遍历所有碰撞盒,来返回碰撞相关的结果。他会产生3个返回值,前两个是位置,后一个是碰撞结果。注意:反弹类型的碰撞并不会帮你把游戏对象的速度改变。而是根据碰撞盒交叠的情况来计算碰撞后的位置。
collision是碰撞的结果,是一个表,通过遍历这个表你会得到所有跟他有碰撞的碰撞盒。通过判断他们对应的游戏对象,我们来决定自己的游戏对象产生何种行为,比如速度取反,加速度停止,受到伤害,或者得到金币等。

其他部分实际我们上一章已经学习过了。这里不再多说。

作业:

  1. 增加背景音乐,开炮声音,击中声音,爆炸声音等。
  2. 新增一些敌人。具有不同的行为表现。比如速度慢飞机子弹比较多,速度快的飞机本身也具有碰撞(自毁型)。
  3. 增加一些道具。道具实际上跟子弹在行为表现上差不多。移动比较慢,或者随机移动。碰撞后的行为比如增加生命,全屏杀伤,等等。
  4. 增加其他种类枪。这个函数主要加在飞机开火的位置。原来生成1个,现在生成2个,3个等等。然后手动的去修改他们的方向和位置。
  5. 增加枪的行为表现,比如跟踪的(算法同跟踪鼠标),先慢后快的,绕圈的等等。

本章代码

由于本章主要体现代码结构,附件已打包。请自行下载。